Java NIO - 操作系统的四种 IO 模型以及 netty 的底层原理

内核态进程和用户态进程

怎么理解内核和用户

操作系统为了自保,防止普通进程直接影响整个系统,建立了一套严格的等级机制。它就像一个等级森严的堡垒,所有的划分都是为了防止 “平民” 误伤 “国王”。为了充分理解内核和用户,我们从以下三个角度来剖析这个内核-用户的机制。

从物理内存上看:

  • 内核空间 (Kernel Space): 内存中最高级的区域,存放的是操作系统的核心代码和数据。它是常驻内存的,且拥有操作一切硬件的 “绝对权力”。
  • 用户空间 (User Space): 内存中留给普通应用程序(如你的浏览器、编辑器)运行的区域。

每个用户进程都有自己独立的虚拟用户空间,这就像给每个程序发了一个“小黑屋”,它们在屋里怎么折腾都行,但绝对摸不到墙外的内核空间。应用程序想要拿硬件里的数据,不能自己去搬,必须求助于内核,这种求助内核的操作,就叫系统调用(System Call)。

CPU 的运行模式来看:

  • 内核态 (Kernel Mode / Ring 0): 又称特权模式。相当于最高行政权限。当 CPU 处于这个状态时,可以执行任何指令,访问任何内存地址,直接控制磁盘、网卡等硬件。
  • 用户态 (User Mode / Ring 3):相当于受限平民权限。在这种状态下,CPU 只能执行不具破坏性的简单指令,不允许直接操作硬件。如果用户程序想读写硬盘,CPU 必须从用户态切换到内核态,这种切换的代价往往很高,需要保存寄存器上下文,相较来说很耗时。

从任务执行的主体来看:

  • 内核进程(通常指内核线程 Kernel Thread): 它们由内核直接创建和调度,始终运行在内核空间和内核态。它们负责 “后台杂务”,比如定期把内存数据刷到磁盘、管理网络连接。它们不需要 “系统调用”,因为它们本身就是系统的一部分。
  • 用户进程: 这就是我们平时写的代码或运行的软件(如 Chrome、IDE)。它们主要在用户空间活动。当它们需要申请内存、读写文件时,会通过系统调用临时 “越境” 进入内核态。

总结来说就是:一个[用户进程]运行在[用户空间]内,此时 CPU 处于[用户态];当它需要读写磁盘时,通过系统调用切入[内核态],此时 CPU 进入[内核空间]去执行[内核代码]来操作硬件。


Read 和 Write 的本质

用户程序进行IO的读写依赖于底层的IO读写,基本上会用到底层操作系统的 read 和 write 两大系统调用。虽然在不同的操作系统中 read 和 write 两大系统调用的名称和形式可能不完全一样,但是它们的基本功能是一样的。操作系统层面的read系统调用并不是直接从物理设备把数据读取 到应用的内存中,write系统调用也不是直接把数据写入物理设备。为了更好理解这一点,我们从以下三个角度来说明:

第一:read/write 并不真的负责读写

在程序员眼中,我们是在 “读写文件”;但在操作系统眼中,我们只是在 “搬运内存”。在我们的思维惯性里,调用 read 就像从井里打水。但实际上,系统调用从来不负责 “数据生产”,只负责 “搬运数据”。

  • read:只是把已经打好、放在桶里(内核缓冲区)的水,倒进你的杯子(用户缓冲区)里。
  • write:只是把你的水倒进桶里,然后告诉你 “倒完了”,至于桶里的水什么时候泼到地里(硬件),它概不负责。

第二:I/O 操作其实是“缓存拷贝”

真正的物理 I/O(磁盘寻道、网卡收发)极其缓慢,而内存读写极快。为了调和这种矛盾,内核在中间修了一个巨大的 “中转仓”:

我们在代码里感受到的 “I/O 延迟”,绝大部分时间其实是消耗在等待内核把 “中转仓” 填满(或排空)的过程中。

第三,所有的 I/O 本质都一样

无论是操作一个本地文件(File I/O),还是操作一个网络连接(Socket I/O),在 Linux 内核看来,它们的操作流程几乎完全重合:

  • 输入(Input): 硬件 $\rightarrow$ 内核缓冲区 $\rightarrow$ 用户缓冲区。
  • 输出(Output): 用户缓冲区 $\rightarrow$ 内核缓冲区 $\rightarrow$ 硬件(网卡、磁盘等)。

这种高度的一致性,就是 Unix 哲学中 “万物皆文件” 的底层逻辑。

所以,理解了“读写即拷贝”,你就才能瞬间明白高性能编程的优化方向:

  • 为什么要用缓冲区? 减少系统调用。一次搬一桶水,比一勺一勺搬效率高得多。
  • 为什么要用零拷贝(Zero-Copy)? 既然数据只是在缓冲区跳来跳去,能不能直接让用户程序去读内核的缓冲区?
  • 为什么要用非阻塞 I/O? 当 “中转仓” 还没水的时候,不要让搬运工(线程)死等,先去干别的,有水了再来。


典型的系统调用流程

用户程序所使用的系统调用read和write并不是使数据在内核缓冲 区和物理设备之间交换:read调用把数据从内核缓冲区复制到应用的 用户缓冲区,write调用把数据从应用的用户缓冲区复制到内核缓冲区。具体到 Java客户端和服务端之间完成一次socket 请求和响应(包括read和write)的数据交换,其完整的流程如下:

  • 客户端发送请求:Java客户端程序通过write系统调用将数据复制到内核缓冲区,Linux将内核缓冲区的请求数据通过客户端机 器的网卡发送出去。在服务端,这份请求数据会从接收网卡中 读取到服务端机器的内核缓冲区。
  • 服务端获取请求:Java服务端程序通过read系统调用从Linux内 核缓冲区读取数据,再送入Java进程缓冲区。
  • 服务端业务处理:Java服务器在自己的用户空间中完成客户端 的请求所对应的业务处理。
  • 服务端返回数据:Java服务器完成处理后,构建好的响应数据 将从用户缓冲区写入内核缓冲区,这里用到的是write系统调用,操作系统会负责将内核缓冲区的数据发送出去。
  • 发送给客户端:服务端Linux系统将内核缓冲区中的数据写入网卡,网卡通过底层通信协议将数据发送给目标客户端。


四种主要的IO模型

首先,解释一下阻塞与非阻塞。阻塞IO指的是需要内核IO操作彻底完成后才返回到用户空间执行用户程序的操作指令。阻塞就是用户程序(发起IO请求的进程或者线程)的执行状态。可以说传统的IO模型都是阻塞IO模型,并且在 Java 中默认创建的 socke t都属于阻塞IO模型。在 Java 默认的 Socket 里,read() 方法一旦调用,你的线程就被 “钉” 死在那行代码上了,直到数据拷贝完成。

其次,关于同步和异步,可以将同步与异步看成发起IO请求的两种方式。同步IO就是指用户进程或线程是主动发起IO请求的一方,系统内核是被动接收方。而异步IO则反过来,系 统内核是主动发起IO请求的一方,用户空间是被动接收方。

  • 同步意味着你是“讨债人”:无论是阻塞还是非阻塞,只要是你(用户空间)主动调用 read、主动去查状态、主动去搬运数据,这都叫同步。内核只是个被动响应的服务员。
  • 异步则反过来,你是 “甩手掌柜”。你给内核留个地址和电话(回调函数),然后去睡觉。内核(主动方)负责把数据从硬件读好,再主动塞进你家冰箱,最后打电话叫醒你。

从阻塞和同步的角度考虑,我们将主流的系统IO模型分成四类:同步阻塞IO、同步非阻塞IO、IO多路复用、以及异步IO。

注意:同步非阻塞IO也可以简称为NIO,但是它不是Java编程中的 NIO。Java编程中的NIO(New IO)类库组件所归属的不是基础IO模 型中的NIO模型,而是IO多路复用模型。

下面为了让你彻底看清这四种模型的 “真身”(以read为例),我们必须盯着两个阶段:

  1. 数据准备阶段(硬件 $\rightarrow$ 内核缓冲区)
  2. 数据拷贝阶段(内核缓冲区 $\rightarrow$ 用户缓冲区)


同步阻塞 IO

用户进程调用 read,此时用户进程挂起。内核开始等数据,数据到了之后,内核亲自把数据拷贝到用户空间,拷贝完了,read 才返回,用户进程继续。在整个过程中,用户全程死等,从发起请求到数据进屋,你什么都干不了。


同步非阻塞 IO

以发起一个非阻塞socket的read操作的系统调用为例,具体流程如下:

  • 第一阶段,发起调用:用户进程调用 read 系统调用。此时内核会查看内核缓冲区,如果数据还没准备好,内核不会把进程挂起,而是立即返回一个错误码(在 Linux 中通常是 EAGAIN 或 EWOULDBLOCK)。

  • 第二阶段:轮询检查:用户进程拿到错误码后,并不会阻塞,它可以去干点别的(比如算个数、打个日志),但它必须不断地轮询调用 read,就像个急躁的顾客,每隔几毫秒就问柜台:“我的货到了吗?”

  • 第三阶段:数据到达与搬运。当某一次轮询时,内核发现数据已经从硬件(网卡/磁盘)传到了内核缓冲区,此时内核不再返回错误码,而是开始执行数据拷贝,在拷贝过程中,用户进程是 阻塞 的。拷贝完成后,read 调用返回成功。

总体来说,在高并发应用场景中,同步非阻塞IO是性能很低的, 也是基本不可用的,一般Web服务器都不使用这种IO模型。因为:

  • 进程在轮询期间,CPU 是 100% 占用在 “询问” 上的。这就像你每秒钟打一个电话问快递,CPU资源占用很高。
  • 如果有 1000 个连接,你就要写一个循环调 1000 次 read。即便 999 个都没数据,你也要挨个问一遍。

总结来说,非阻塞 I/O 的本质就是用 “CPU 的忙碌” 换取 “线程的不挂起”。 正因为轮询起来太累,所以才催生了多路复用——既然轮询 1000 次太费劲,不如我把这 1000 个连接都交给内核(管家),内核发现谁有数据了,一次性告诉我。这就是 Netty 真正起飞的起点。


IO 多路复用

要透彻理解 IO多路复用,尤其是 Linux 下的王牌 epoll,你只需要记住它的核心使命:用一个内核线程,同时监控成千上万个连接的状态。在同步非阻塞 IO 中,进程需要自己不断轮询每一个 Socket;而在IO多路复用中,进程把这个苦差事交给了内核。

目前支持IO多路复用的系统调用有select、epoll等。几乎所有的 操作系统都支持select系统调用,它具有良好的跨平台特性。epoll是 在Linux 2.6内核中提出的,是select系统调用的Linux增强版本,select 的 FD_SETSIZE 限制通常只有1024,而 epoll 则无硬性限制,在云计算和微服务时代,单机维护数千甚至上万连接已是常态。select 的 1024 限制使其完全无法胜任现代需求。epoll 是当下 Linux 高并发网络编程的主流和事实标准,而 select 目前基本只存在于历史教材。

  • select:每次调用都要把整个 fd 集合(哪怕没变化)从用户空间拷贝到内核。像老师每次点名,即使只有1个学生要回答问题,老师也必须按名单念完所有1000个学生的名字,复杂度 O(n);
  • epoll 通过 epoll_ctl() 建立 fd 到内核的注册表,后续 epoll_wait() 只返回就绪的 fd。像学生主动举手,老师只需要看哪些学生举了手,直接叫他们回答,复杂度 O(1),只返回就绪的fd。
1
2
3
4
5
6
7
8
9
10
11
12
13
// Netty 的默认选择
public class NioServerSocketChannel extends AbstractNioMessageChannel {
// Linux 下默认使用 epoll
// 可通过 -Dio.netty.transport.epoll=true 开启
}

// Nginx 的配置
# ./configure 时自动检测 epoll
# 在 event 块中明确指定
events {
use epoll;
worker_connections 65535; // 轻松支持数万连接
}

下面就以 epoll 为例介绍IO多路复用的过程。我们可以把 epoll 的工作过程拆解为三个关键动作:开户、挂号、等通知。

  • 第一步,创建 epoll 句柄(开户 - epoll_create):用户进程告诉内核 “我要开始监控大量连接了,请给我开个特殊的监控管理处”。内核会返回一个文件描述符(fd),这个 fd 就是以后你和内核沟通这个 “监控任务” 的凭证。

  • 第二步,添加监控事件(挂号 - epoll_ctl):你有 10,000 个 Socket 连接。你不需要循环调用 read 去问,而是通过 epoll_ctl 告诉内核 “帮我盯着这 10,000 个 Socket,只要其中任何一个有数据进来了(可读事件),你就记录下来”。这个动作只在连接建立时做一次,不需要像 NIO 那样每次都重复询问。

  • 第三步,等待内核通知(等通知 - epoll_wait):内核线程调用 epoll_wait 后会阻塞。这里的阻塞是有意义的。内核会一直盯着那 10,000 个 Socket。当某几个 Socket 真的有数据到了,内核会把这几个“活跃”的 Socket 放进一个就绪列表中。epoll_wait 立即返回,并把这个 “就绪列表” 传给用户进程。

  • 第四步,将数据从内核缓冲区拷贝到用户缓冲区:用户进程一旦接收到就绪通知,它就可以把数据从内核缓冲区拷贝到用户缓冲区,但这依然是由你的进程亲自、同步完成的,这也就是多路复用依然被归类为同步 IO 的根本原因,只要你需要亲自搬运数据,你就没法彻底 “甩手”,所以它不是异步。

Epoll 就像是一个高效的快递代收点。你不需要每天给 100 个快递员打电话(NIO 轮询),只需要等代收点的一条短信(epoll_wait),然后自己去取件(同步拷贝数据)即可。

Netty 框架使用的就是IO多路复用模型。总结起来,Netty 能够支撑百万并发,本质上就是把多路复用到了极致:

  • BossGroup 线程 专门调 epoll_wait 盯着新的连接请求。
  • WorkerGroup 线程 专门调 epoll_wait 盯着已有连接的数据读写。
  • 零拷贝 拿到通知后,用最快的速度把数据处理掉,不浪费任何一次多余的搬运。


异步 IO (AIO)

用户进程调用 aio_read,然后直接去干别的。内核自己等数据、自己搬数据。等数据稳稳当当地躺在用户进程的缓冲区里了,内核发个信号:“搞定了,你直接用吧。” 看起来是不是很完美?你可能觉得 AIO 才是终极武器,但 Netty 选了 epoll(多路复用),原因是:

  • 拷贝成本的控制权: 在 epoll 下,数据准备好了通知你,你决定什么时候、用多大内存去拷贝。在 AIO 下,内核主动往你内存里塞,高并发下你极难控制内存的瞬时激增。
  • Linux 的偏心: Linux 对 epoll 做了极尽升华的优化,而对 AIO 的网络支持长期处于 “半残” 状态。在 Linux 上,epoll 配合零拷贝(如 sendfile),性能已经和 AIO 几乎没有代差,但稳定性强出几个数量级。


牛逼的零拷贝技术

零拷贝的原理和实现

上面我们已经说过,传统 IO 很多时候会进行无意义的 CPU 搬运。比如你要把磁盘上的一个文件通过网卡发出去。在标准 IO 模型下,数据会经历以下路径:

  1. 磁盘 $\rightarrow$ 内核缓冲区(DMA 搬运,即 Direct Memory Access)
  2. 内核缓冲区 $\rightarrow$ 用户缓冲区CPU 搬运,第 1 次拷贝)
  3. 用户缓冲区 $\rightarrow$ Socket 缓冲区CPU 搬运,第 2 次拷贝)
  4. Socket 缓冲区 $\rightarrow$ 网卡(DMA 搬运)

数据在内存里被复制了两次,且 CPU 参与了这种机械搬运。在高并发下,CPU 的时间全花在 “拷贝”上了,根本没空处理业务逻辑。零拷贝的思路就是 “消除中间商”,即 消除内核空间与用户空间之间的数据交换。怎么实现的呢?两种方式:

实现方式 A:mmap + write(内存映射)

  • 原理:操作系统把内核缓冲区的一段地址,直接映射到用户空间。
  • 过程:你的程序和内核“共享”同一块内存。你调用 write 时,内核直接从这块共享区域把数据拷到 Socket 缓冲区。
  • 效果: 减少了 1 次 CPU 拷贝。虽然还有 3 次拷贝,但用户空间不再持有数据副本。

实现方式 B:sendfile(真正的直通车)

  • 原理:Linux 提供的系统调用。它告诉内核:“直接把 A 文件的内容发到 B 套接字去”。
  • 过程:数据根本不经过用户空间。数据从内核缓冲区直接进入 Socket 缓冲区(或通过 SG-DMA 直接发给网卡)。
  • 效果:只有 2 次拷贝(磁盘$\rightarrow$内核$\rightarrow$网卡),且 CPU 参与度为 0。


零拷贝在 Netty 中的应用

前面我们讲,Netty 之所以快的原因之一,是因为它在不同层面压榨了零拷贝的价值:

  • 操作系统层(Direct Buffer): Netty 优先使用堆外内存(DirectByteBuf)。数据直接在内核和物理内存间交互,避免了从 JVM 堆内存到内核的二次拷贝。

  • 传输层(FileRegion): 在发送文件时,Netty 直接封装了 Java 通道的 transferTo 方法(底层就是 sendfile),数据直接从文件通道流向网络通道。

  • 应用层(CompositeByteBuf): 这是一种 “逻辑组合”。如果你要把两个数据包合并,传统做法是开个新大数组把它们考进去;Netty 则是把两个包 “逻辑挂载” 在一起,看起来是一个整体,实际上物理内存一行都没动。


零拷贝也不是万能药

零拷贝不是万能药,它有明确的适用场景,这些场景包括:静态资源分发(如 Nginx 发送图片)、大文件传输、消息队列(如 Kafka 消费消息)。它们的特点是:我只负责搬运,我不修改数据。如果你需要对数据进行复杂的加密、压缩或修改,你还是得把数据搬回 “用户空间” 的小黑屋里处理。


高并发操作系统层面的配置

要支持百万级并发连接(C1000K),单靠代码层面的 epoll 或 Netty 是远远不够的。你必须从 操作系统内核网络协议栈应用程序 进行全方位的 “扩容”。

如果把并发连接比作 “高速公路上的车辆”,那么配置的目标就是:增加车道数(句柄)、缩短收费站排队时间(内核参数)以及扩充服务区容量(内存)


操作系统层

Linux 默认配置是为了通用服务器设计的,对于百万并发来说,这些默认值就像是给摩天大楼装了单人电梯。


文件描述符

在 Linux 中,每个连接(Socket)都是一个文件。默认的限制通常只有 1024。

  • 系统级限制:cat /proc/sys/fs/file-max(整个内核能打开的最大文件数)。
  • 进程级限制: ulimit -n(单个进程能打开的最大文件数)。

设置系统最大文件描述符数量,建议 file-max ≥ (单个进程最大fd数 × 最大进程数) × 1.2(安全系数),典型配置参考:

  • 常规 Web / 微服务:100万,覆盖数百个进程,单进程几千fd的常规场景
  • API 网关 / 长连接服务:200万-500万,单机维持大量 Socket 连接,消耗巨大
  • 数据库服务器:50万-100万,连接数相对可控,但需考虑表文件打开数
  • 容器环境 (Docker/K8s):需显式设置,容器默认继承宿主机超大值,建议按 Pod 实际需求调小以保安全
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#### 设置系统最大文件描述符数量 
# 1. 查看当前系统全局限制
cat /proc/sys/fs/file-max
# 2. 查看当前已使用量(系统级统计),输出三列:已分配、未使用但分配了、最大值
cat /proc/sys/fs/file-nr
# 3. 临时修改(重启失效,适合紧急扩容)
echo 2000000 > /proc/sys/fs/file-max
# 或使用 sysctl 命令
sysctl -w fs.file-max=2000000


#### 永久配置(重启不失效):
# 编辑 /etc/sysctl.conf 文件,在文件末尾添加:
fs.file-max = 2000000
# 保存后使其立即生效。
sysctl -p

file-max只是第一道关卡,进程级限制(ulimit)往往先触顶,必须同步调整。

1
2
3
4
# 修改进程极限:vim /etc/security/limits.conf
# 这里设置为 2^20,即 104 万左右。
* soft nofile 1048576
* hard nofile 1048576

对于大多数生产服务器,将 fs.file-max 设置为 100万 是一个安全且充裕的起点。重点应放在应用层:确保你的服务(如 Java 应用)的 -XX:-MaxFDLimit 或 Go 应用的 GOMAXPROCS 以及 ulimit 配置与之匹配,避免出现 “系统有空闲,但进程报 too many open files” 的尴尬情况。

端口范围

端口范围是客户端高并发场景的 “隐形杀手”。它决定了你的服务器作为客户端(如微服务调用、连接数据库、调用第三方API)时,可用的临时端口数量上限。一旦耗尽,就会报 Cannot assign requested address 错误。在实际配置的时候应注意:1024 以下(1-1023)是系统保留端口(如 80、22),普通进程无法绑定。从 1024 开始才是安全起点。

在实际分析需要多少端口范围时,比如如果每秒有 1 万次短连接请求,且 TIME_WAIT 持续 60 秒,理论上需要 60 万个端口,远超 6 万上限。不同场景的配置建议:

  • 纯服务端(如 Web 服务器只接收请求):不主动向外发起大量连接,端口够用,默认值即可(32768 60999)
  • 微服务客户端(如 API 网关、Sidecar):需频繁调用下游服务,应扩大池子,可设置 1024 65000(约 6.3 万端口)
  • 代理服务器 / 爬虫节点:极端高并发客户端场景,榨干所有可用端口,可以设置 1024 65535(最大值)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#### 临时查看和修改:
# 查看当前范围
cat /proc/sys/net/ipv4/ip_local_port_range
# 临时扩大范围(注意:1024 以下为系统保留端口,不要使用)
echo "1024 65535" > /proc/sys/net/ipv4/ip_local_port_range
# 或使用 sysctl
sysctl -w net.ipv4.ip_local_port_range="1024 65535"


#### 永久修改(写入配置文件)
# 编辑 /etc/sysctl.conf或 /etc/sysctl.d/99-local.conf,添加
net.ipv4.ip_local_port_range = 1024 65535
# 执行立即生效
sysctl -p


#### 配合下面几个参数来治本
# 1. 开启 TIME_WAIT 复用(关键!),允许新连接复用处于 TIME_WAIT 的端口(解决端口耗尽最有效的手段)
net.ipv4.tcp_tw_reuse = 1
# 2. 加快 TIME_WAIT 回收(激进策略,适合内网微服务),缩短 TIME_WAIT 持续时间(默认 60s,可设为 30s)
net.ipv4.tcp_fin_timeout = 30
# 3. 开启快速回收,注意:Linux 4.12+ 已移除该参数,且 NAT 环境禁用
net.ipv4.tcp_tw_recycle = 0


网络协议栈层

内核在处理三次握手和断开连接时,有很多缓冲区和超时等待,必须压缩。

TCP全连和半连

当大量请求瞬间涌入,如果队列满了,新的连接会被直接丢弃。

全连接队列参数 somaxconn 的配置:当客户端完成三次握手后,连接会放入全连接队列,等待应用调用 accept() 取走。此参数定义了单个监听套接字全连接队列的最大长度。生产环境配置应注意:

  • 必须与应用程序配合:somaxconn 只是系统上限。应用程序在调用 listen(fd, backlog) 时,传入的 backlog 参数(如 Nginx 的 listen 80 backlog=65535)才是最终生效值。取两者最小值。
  • 容器环境:在 Docker/K8s 中,容器内的 somaxconn可能受 Cgroup 限制或与宿主机隔离,需在容器启动时显式设置(如 –sysctl 或 Pod securityContext)。
  • 为什么“直接设 65535”是错的?
    • 内存开销:每个连接在队列中都会占用内核内存。在百万级连接下,过大的队列会消耗大量内存而引发 OOM。
    • 响应延迟:队列过长意味着连接在内核中等待 accept()的时间变长,对于低延迟服务(如游戏、金融),这比直接拒绝连接更糟糕。
    • 掩盖问题:如果队列无限大,客户端不会立即失败,但服务端应用可能因处理不过来而雪崩。有时适当的丢弃(配合重试)比无限制的排队更健康。

最终 somaxconn 的建议值:

  • 常规 Web 服务:16384(平衡内存与并发能力)
  • API 网关 / 高并发代理:32768(需应对突发流量)
  • 长连接/低频服务:4096(连接建立不频繁,无需过大)
  • 容器 (Pod):8192(单 Pod 资源受限,不宜过大)
1
2
3
4
5
6
# 查看当前值(通常默认是 128,极低)
cat /proc/sys/net/core/somaxconn
# 临时修改
echo 65535 > /proc/sys/net/core/somaxconn
# 永久修改(/etc/sysctl.conf)
net.core.somaxconn = 65535

半连接队列的配置:存放已收到 SYN 但未完成三次握手的连接。但在 Linux 2.6 之后,其行为已发生根本变化。

  • 现代内核逻辑:半连接队列长度 ≈ min(somaxconn, tcp_max_syn_backlog),且受 syncookies 机制影响极大。
  • syncookies的干扰:当 net.ipv4.tcp_syncookies = 1(默认开启,防 SYN Flood 攻击)时,在攻击压力下内核会忽略队列长度,直接使用 Cookie 机制,此时 tcp_max_syn_backlog 的设置无意义。
  • 最终的建议是不要单独纠结此参数。实际应优先保证 somaxconn足够大,并保持 syncookies=1 的默认安全配置。

实际配置参考:

1
2
3
4
5
6
7
# 修改 /etc/sysctl.conf
net.core.somaxconn = 32768 # 建议值:16384 ~ 65535,根据内存和业务量级定
net.ipv4.tcp_max_syn_backlog = 16384 # 保持与 somaxconn 同量级即可,非关键
net.ipv4.tcp_syncookies = 1 # 保持开启,防攻击

# 执行 sysctl -p 生效
sysctl -p

应用层(关键,否则系统配置无效):

Nginx:在 nginx.conf 的 listen 指令中显式设置 backlog。

1
2
3
server {
listen 80 backlog=32768; # 必须 <= net.core.somaxconn
}

Java (Tomcat/Netty):在 server.xml 或启动参数中设置 acceptCount(Tomcat)或 SO_BACKLOG(Netty)。


内存缓冲区

每个 Socket 都会占用读写缓冲区。如果 100 万个连接每个占用 100KB,内存就要 100GB!

实际配置:调小初始内存,允许动态扩容。直接将下面配置写入 /etc/sysctl.conf:

1
2
3
4
5
6
7
8
9
10
#### vim /etc/sysctl.conf
##以下是典型的适用于百万级连接场景:
# 第一项(4096):保底内存。防止内核分配失败,也是空闲连接的最小开销。
# 第二项(87380/ 65536):初始窗口。这是 Linux 的 “经验值”,对应中等吞吐量的初始分配。
# 第三项(67108864):硬性上限。防止单个连接占用过多内存,避免 OOM(内存耗尽)。
net.ipv4.tcp_rmem = 4096 87380 67108864
net.ipv4.tcp_wmem = 4096 65536 67108864

# 执行 sysctl -p生效
sysctl -p

记住这个逻辑:最小值保证连接存活,初始值由应用层(Nginx/Netty)决定,最大值防止单个连接吃光内存。不要试图通过调大缓冲区来“加速”网络,真正的性能瓶颈通常是带宽和延迟,而不是缓冲区大小。


应用层配置(Netty)

线程模型配置

  • BossGroup:负责 accept,通常 1 个线程足以处理百万连接的接入。
  • WorkerGroup:负责 I/O 读写。通常设置为 CPU 核心数 * 2

内存管理

主要考虑使用池化与堆外技术:

  • 使用 PooledByteBufAllocator:避免频繁创建和销毁缓冲区导致的 GC 压力。
  • 使用 DirectBuffer:配合零拷贝,直接在内核态与物理内存间交换数据,减少 JVM 堆内存压力。